iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 27 篇

【 Day 26 】量了又畫、畫了又量,迴圈在哪裡停下來?|真實 DOM(四完)

  • 分享至 

  • xImage
  •  

過去三天,我們從一份二十列的清單出發,探索了三個問題:為什麼剛畫出來的東西,會讓剛算好的位置變成錯的;誰被觀察、量到的尺寸又怎麼記;以及量到不一樣之後,捲動位置該不該補償。

TanStack Virtual 與 react-virtuoso 給了不同的答案。TanStack Virtual 讓每一列各自被觀察、一列記一筆,該不該補償交給一個可以換掉的判斷;react-virtuoso 只觀察裝著列的容器,把相鄰同高的列併成一段,只在往上捲時補償,而這個判斷留在它的內部。

可是補償一寫進 scrollTop,要畫哪幾列也可能跟著變。新畫出來的列又會被量到,量到的結果,又可能再改一次位置。

這個迴圈,兩邊各自在哪裡停下來?

今天是探索真實 DOM 的最後一天,讓我們先讓這個迴圈真的轉起來,再來看看它靠什麼停止,又停在什麼位置。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 ✓ · 真實 DOM ✓

讓迴圈轉起來

前兩天的實驗跑在 Chrome 裡。ResizeObserver 在開始觀察與尺寸變化時回報,不會因為我們又畫了一次,就把所有尺寸重新報一次。因此,只看畫面,不容易看清楚這個迴圈轉了幾圈。

這次,我把瀏覽器換成一個模擬的版本。它知道每一列真正多高,清單不知道,只能等它回報。

而且它刻意反覆回報:每畫一次,就把畫出來的每一列都報一次,沒變的也報。一份清單如果認不出「這次量到的,跟上次一樣」,就可能一直通知下去。

這個模型不重現瀏覽器完整的排程。它要檢查的是一件比較小的事:收到重複的尺寸時,清單還會不會繼續更新。

模擬的瀏覽器在一幀之內,做的事情是這樣:

// 模擬的瀏覽器:一幀之內
list.subscribe(() => {
  dirty = true;
});

const frame = (): boolean => {
  let passes = 0;
  while (dirty || measureDue) {
    passes += 1;
    if (passes > 50) return false; // 一幀裡重畫超過 50 次,就當成停不下來

    if (dirty) {
      dirty = false;
      render(); // 照清單給的範圍與位置畫出來
      measureDue = true;
    }
    if (measureDue) {
      measureDue = false;
      report(); // 把畫出來的每一列實際多高,全部交給 list.measure
    }
  }
  return true;
};

清單一通知,模擬器就重畫;畫完就回報;回報又可能讓清單再通知一次。量了又畫、畫了又量的迴圈,在這裡寫成了一個 while。

一幀裡處理超過 50 輪,就當成這次實驗沒有停下來。清單排到下一幀的工作,會在下一幀開頭先執行;跑完 50 幀還有工作,也做同樣的判定。這是測試的停止上限,不是瀏覽器的規則。

接著,我把前幾天的三份清單改寫在同一個介面上。三份的位置算法都和前幾天相同,差別在 measure 收到回報之後做什麼。清單透過 host 讀寫 scrollTop,以及把工作排到下一幀。

第一份是 Day 23 的固定高度清單。它多了一個 measure,但收到什麼都直接丟掉。

第二份照 TanStack Virtual 的形狀,沿用 Day 25 的補償規則。量到一列,就當場處理那一列:

// TanStack 臨摹:一列一列處理
const resizeItem = (index: number, size: number): void => {
  const itemSize = sizeOf(index);
  const delta = size - itemSize;
  if (delta === 0) return;

  // 依 Day 25 的兩套規則算出 shouldAdjust(省略)
  itemSizeCache.set(index, size);
  if (shouldAdjust) host.writeScrollTop(host.scrollTop + delta);
  notify();
};

第三份照 react-virtuoso 的形狀,沿用 Day 25 ContainerList 的補償方向。measure 本身不處理量測,只把回報攢起來,排到下一幀:

// Virtuoso 臨摹:攢到下一幀
measure(entries) {
  pending.push(...entries);
  if (frameRequested) return;
  frameRequested = true;
  host.requestFrame(flush);
},

這份臨摹用 pending 和 frameRequested,把下一幀之前收到的回報合併成一批。到了下一幀,flush 再把這一批交給 process:

// Virtuoso 臨摹:下一幀,一批處理
const process = (entries: readonly Measurement[]): void => {
  const next = reduceSizes(sizes, entries);
  if (next === sizes) return;

  const before = totalSize();
  sizes = next;
  upwardScrollFix(totalSize() - before);
  notify();
};

reduceSizes 把這一批量測寫進 Day 24 那份區間;如果尺寸狀態沒變,它交回的就是同一個陣列。upwardScrollFix 則是沿用 Day 25 的規則:總高度變了,而且正在往上捲,才補償。

它停下來了嗎

讓我們準備一份二十列的資料:第 0、3、6⋯⋯18 列實際高 70px,其餘 40px,和估計值相同。視窗高 200px,上下各多畫兩列。

打開清單之後,捲到 scrollTop 300px,看看三份各自花了多少功夫才停下來:

清單 重畫次數 停在第幾幀
固定高度 1 1
TanStack 臨摹 2 1
Virtuoso 臨摹 2 3

這裡的「重畫次數」,指模擬器呼叫 render() 的次數,不是 React 重新渲染元件的次數,也不是瀏覽器繪製畫面的次數。「幀」也指模擬器推進的一步,不能拿來直接比較真實瀏覽器裡的速度。

TanStack 臨摹在同一幀裡就停了。第一次畫出來,新進場的列裡有量到 70px 的,delta 不是零,於是它記下尺寸、通知重畫。第二次畫出來,量到的都和記下的一樣,delta 全是零,沒有人再通知,這一幀就結束了。

Virtuoso 臨摹也只重畫兩次,但多走了兩幀。第一幀畫出來、回報,量測只是被攢起來。第二幀處理這一批,區間變了,通知重畫,畫完又回報一次,再攢起來。第三幀處理時,reduceSizes 交回同一個陣列,它才停下來。

最後一幀沒有再更新尺寸,也沒有重畫;它只確認了這次回報不會改變狀態。

固定高度那一份停得最快,畫一次就停了。可是它畫出來的第 6 列實際 70px,算出來的位置卻只留了 40px,壓到下一列 30px。

它之所以停得快,是因為量測從來沒有進入位置計算。迴圈沒有繼續,錯誤也沒有被修正。

那麼,讓兩份量測的清單停下來的,到底是哪一行?

我們把它們認出「一樣」的那一行各自拿掉,再跑一次同樣的捲動:

  • TanStack 臨摹拿掉 if (delta === 0) return;:量到一樣的列也照樣通知,在同一個模擬幀裡持續重畫,直到超過測試上限。
  • Virtuoso 臨摹讓 reduceSizes 每次都交回新的陣列:每一幀重畫一次,跑完 50 幀仍繼續工作。

在這個實驗裡,兩份清單讓迴圈停止的,是同一件事:認出新的回報沒有改變尺寸狀態。

拿掉之後,兩份停不下來的樣子卻不一樣。TanStack 臨摹在同一幀裡持續轉;Virtuoso 臨摹把迴圈攤到後續幀。

每幀只處理一批,改變的是更新發生的時機。它本身沒有提供停止條件。

這個結果也有一個前提:每列的真實高度固定。重複回報最後會遇到同一個值,守衛讓清單在那裡停止。若版面讓尺寸本身反覆改變,每次量到的都不同,還得另外追查變化的來源。

五個現場

只看會不會停還不夠,迴圈也要停在對的地方。我把前幾天看過的情境整理成五個現場,三份清單跑同一組檢查。

有些檢查要求列一列接一列,不重疊、不留縫;有些要求你正在讀的那一列,不因量測而移動。另有一項專門檢查補償的寫入次數。

這五列量的不是完全相同的事。紅格表示沒有滿足該列的要求,不能直接加總成誰比較好的分數。

場景一律是二十列,不需要虛擬化也畫得完。要追的問題,是量測與位置之間的關係。

  • 首次量測:往上捲,一列從上方進場,第一次量到它比估計值高。Day 25 那次拖到最底再往上捲,就是這個情況。
  • 視窗之上長高:往下捲後停住,視窗之上一列已經量過的列長高了,像 Day 25 的第 9 列。
  • 跨上緣從下方長高:橫跨視窗上緣的那一列,從下緣往下長,像 Day 25 串流的第 10 列。
  • 同幀多列只寫一次:往上捲一大步,三列同時從上方進場、在同一次回報裡。除了位置正確,這項還要求三列的補償只寫一次 scrollTop。
  • 量測 → 重新渲染 → 再量測能收斂:剛才那個現場。停下來之後再跑一幀,什麼都不該發生。

pnpm test 印出的結果是:

斷言 固定高度(基線) TanStack Virtuoso
首次量測 🔴 🟢 🟢
視窗之上長高 🔴 🟢 🔴
跨上緣從下方長高 🔴 🟢 🟢
同幀多列只寫一次 🔴 🔴 🟢
量測 → 重新渲染 → 再量測能收斂 🔴 🟢 🟢

固定高度整欄都是紅的,理由都是同一個:它不量。最後一格它其實停下來了,只是排列不正確,所以沒有通過這項合併了停止與位置要求的檢查。

TanStack 臨摹紅在「同幀多列只寫一次」。三列一起進場,它一列補一次,同一幀寫了三次 scrollTop。不過最後的 scrollTop 是 560px,你正在讀的第 13 列也停在原地,和 Virtuoso 臨摹寫一次的結果相同。

這一格沒有滿足的是寫入次數,最後的位置仍然正確。

Virtuoso 臨摹紅在「視窗之上長高」。這是 Day 25 已經看過的結果:沒在往上捲的時候,它不補償,你正在讀的那一列就被推下去。

這一格沒有滿足的,是閱讀位置。

兩份量測的清單各紅一格,但紅格的意思不同。前者多寫了兩次,最後位置相同;後者沒有補償,最後位置也不同。這個實驗沒有進一步量測多次寫入在真實瀏覽器裡的成本。

而補償的判斷,兩邊交給了不同的人。

TanStack 臨摹沿用的判斷可以替換。把它換成「上緣在視窗之上就補償」,同樣是串流的第 10 列,「跨上緣從下方長高」那一格就會變紅。迴圈一樣停得下來,只是停在另一個位置。

react-virtuoso 在我們追的這條補償規則上,沒有提供可以直接替換的判斷介面。

讓迴圈停止的機制,兩邊都留在自己的內部。量到不一樣之後該不該補償,TanStack Virtual 則留了一個地方,讓呼叫端接手。

這四天的複雜度,從哪裡來

用 Day 02 的詞來說,這個領域的複雜度,到底是從哪裡長出來的?

只看掛上 ResizeObserver 的那幾行,看不出來。Day 24 讓它看著每一列,只多了幾行;看著容器的版本,也差不多。

難的地方是,有些事實只有瀏覽器知道。一列多高,要先畫出來才能問;問到之後,又得重新計算位置,甚至改變下一次要畫哪幾列。

畫面成了下一次計算的依據,計算又改變下一次的畫面。輸出回到了輸入,迴圈因此出現。

我們在探索瀏覽器 API 的最後一天說過:最簡單的 API,複雜度一點也沒少,只是換了地方。那時候,它換到了 localStorage 跟 React 元件模型接起來的地方。

這一次,接縫本身就是一個回饋迴圈。localStorage 不會因為我們讀了它,就改變裡面的值;量到一列的高度之後,卻可能改變下一次畫出來、拿來量的那幾列。

模組要承擔的,於是不只是讀取尺寸,還有尺寸改變之後的一連串更新。

TanStack 臨摹在每一列上比較新舊尺寸,一樣就不再通知;Virtuoso 臨摹在處理完一批之後比較狀態,沒變就不往下送。前者當場處理,後者安排到下一幀。

原作還有更多更新時機的判斷。例如 TanStack Virtual 的 measureElement 會依捲動狀態決定是否當場讀取尺寸,沒有在捲動時可以直接讀,使用者捲動中則留給 observer 回報。

這些判斷共同承擔了呼叫端原本得自己處理的事:什麼時候量、量到之後怎麼更新,以及什麼時候不必再更新。

深度不只在量測,更在於讓量測帶來的更新能夠收斂。

同一把尺,量到另一端

到了這裡,還剩下一個問題:迴圈停止了,預設補償卻不合用時,呼叫端能怎麼辦?

這還是 Day 17 那個問句:呼叫端還需要知道什麼?

在瀏覽器 API 那一端,我們追的是:Hook 有沒有認出「一個 Storage 的一個 key」才是共享與通知該對準的單位。認出之後,呼叫端就能少管一些同步的細節。

到了真實 DOM 這一端,兩邊都替呼叫端承擔了量測與位置之間的回饋。差別落在另一個地方:補償判斷不合用時,呼叫端有沒有地方接手。

TanStack Virtual 把這個判斷做成公開的屬性,附上預設規則。呼叫端可以沿用;遇到不同的內容與閱讀方式,也可以替換。代價是,一旦接手,就得理解尺寸變化與閱讀位置之間的關係。

react-virtuoso 把我們追的補償判斷留在內部。呼叫端不必先替它決定,但遇到實驗中那種閱讀位置移動的情況,也沒有同樣的判斷入口可以直接替換。

深的形狀不同,而不是哪一邊深得更多。

Day 18 說過,深模組並不是沒有洞,比較像是把洞換到了別的地方。那一次,刪除事件被一行守衛擋在呼叫端看不到的地方。這一次,TanStack Virtual 的預設補償也會遇到不合用的場景,但它把判斷的入口留了出來。

入口不保證呼叫端一定會補對。它決定的是:需要改變這個判斷時,誰有權接手。

因此,真實 DOM 不是多加一個例子,而是讓我們再驗了一次前面得到的答案:同樣問「呼叫端還需要知道什麼」,到了這一端,答案落在那個判斷歸誰。

真實 DOM 結束,接下來呢?

這四天問的都是同一件事:畫出來之後,你算的位置還算數嗎?

答案從一份只用乘法擺位置的清單開始,走到每一列各自被觀察、觀察裝著列的容器,再走到該不該補償,以及今天這個量了又畫的迴圈。

今天把三件事分開看了:收到相同的尺寸時,更新能不能停止;尺寸改變之後,排列與閱讀位置是否正確;預設補償不合用時,呼叫端能不能接手。

從遠端世界到真實 DOM,我們一次只順著一個問題往下走。

至此,六個問題都已經告一段落。在剩下的幾天裡,我們要換個方向,把這六個問題並排,用橫向的視角來看它們。

TanStack Virtual 決定何時當場讀取尺寸的條件,在 virtual-core/src/index.ts 的 measureElement,尺寸不變時不處理則在同檔的 resizeItem;react-virtuoso 把 ResizeObserver 回呼延到下一幀,在 hooks/useSize.ts,尺寸狀態沒變就不往下送,在 sizeSystem.ts 的 sizeStateReducer 與 distinctUntilChanged。取自預設分支原始碼,npm 最新版為 @tanstack/virtual-core 3.18.0、react-virtuoso 4.18.16,查核於 2026-10-10。


上一篇
【 Day 25 】量到不一樣之後,捲動位置該不該補償?|真實 DOM(三)
下一篇
【 Day 27 】六個問題問完了,重複出現的是哪幾個詞?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言